Skip to content

feat: 1.7.0 — dirty baseline, aria wiring, message catalog, async options - #60

Merged
vannt-dev merged 31 commits into
developfrom
feat/1.7-triage-followups
Sep 4, 2026
Merged

feat: 1.7.0 — dirty baseline, aria wiring, message catalog, async options#60
vannt-dev merged 31 commits into
developfrom
feat/1.7-triage-followups

Conversation

@vannt-dev

Copy link
Copy Markdown
Owner

What

Everything in a consumer's 1.5.1 → 1.6.0 upgrade report that survived
verification, as one minor release. Five confirmed bugs fixed, four gaps filled:

  • dirty was measured against a baseline captured at mount and never
    re-based.
    Wrong after reset(newValues) on all three adapters, and on
    React/Vue wrong for values arriving from a fetch after mount — every field
    read dirty: true forever. Adds baselineValues + getDirtyValues() to the
    form store and an initialProperties prop to MultiFieldInput.
  • ariaDescribedBy was hard-coded undefined, so focusFirstInvalidField
    (which selects [aria-invalid="true"]) silently did nothing for anyone
    following the official renderer recipe. Adds makeErrorId, wires the recipe,
    and makes the default renderers render the message they were already handed.
  • debounceMs was a dead API — declared, published in the .d.ts, read by
    nothing anywhere. It now debounces async options loading.
  • React validated the same data twice per keystroke.
  • props shadowing a contract key vanished silently. Dev-mode warning added.
  • New: async options (dependent + search-remote), a validation message
    catalog, and validators.matches.

Why

The report came from integrating this library across a real Task Manager app.
Each claim was re-verified against the 1.6.0 source before being acted on —
three did not hold up, and are recorded as rejected in the design doc rather
than silently dropped:

  • "MultiFieldInput validates without an onValidityChange listener" — false,
    ?.() short-circuits and never evaluates its argument. The real duplicate
    lived in useDynamicForm.
  • "Async validators fire twice per keystroke, doubling network calls" — false,
    validateFields is synchronous and never invokes asyncValidate.
  • "Tree-shaking between the three adapters is unclear" — not a defect; they are
    three separate packages and check-cross-framework-imports already gates it.

Three problems the report did not mention were found and fixed here: the
async-load half of the dirty bug (more common than the reported reset case),
FieldInput's memo comparator hiding data changes from dependent-options
fields, and constructor.name === 'AsyncFunction' not surviving a memoiser or
spy — which let a promise reach a renderer as its option list.

How to test

npm run lint && npm test && npm run build
npm run lint:renderer-parity                        # must report 23 props, was 21
npm run test --workspace=@dynamic-field-kit/smoke   # needs the build above
node scripts/check-docs-api-references.js

The two bug-fix commits each start from a test that failed before them:

git show 94e5d9b --stat   # dirty baseline: reset + late-load
git show 99e9ef2 --stat   # duplicate validation: one validate call per change

Adapter differences, all deliberate and explained in the commits that introduce
them: Angular never had the dirty late-load bug (its init() only records a
baseline once properties is set) and has no form shorthand, so it takes
store.baselineValues() through initialProperties; the duplicate validation
was React-only, since neither the Vue composable nor the Angular store runs a
watch or effect.


  • Added a changeset (npx changeset) if any package under packages/ changed
  • Tests cover the change — a bug fix has a test that failed before it
  • Public API changes are reflected in the README / package README
  • Behaviour is consistent across react, vue and angular, or the difference is explained above

One visible change

Default renderers now render the validation message they receive. They
previously accepted error and dropped it, so a form using the built-ins showed
nothing at all. Custom renderers are unaffected — the node is emitted only where
no renderer is registered, so nobody gets two copies of their own message.
docs/MIGRATING.md calls this out, and .dfk-field-error { display: none }
restores the old silence.

The design doc and the four implementation plans are under docs/superpowers/,
which this repo gitignores by convention, so they stay local.

…Form

The dirty baseline was implicit and unreachable, so nothing outside the
hook could tell which values a form opened with. reset(newValues) now
re-bases it, and getDirtyValues reads it for PATCH-style submits.
The baseline was captured once with useRef at mount and never reassigned,
so every field compared against pre-reset values after reset(newValues),
and against {} when properties arrived from a fetch - reporting the whole
form dirty forever. It now comes from whoever owns the values: the bound
form's baselineValues, an explicit initialProperties prop, or the first
non-undefined properties seen.
handleChange validated eagerly and the [fields, data] effect validated the
identical object again after commit. The effect now skips data it has
already seen, and re-arms when fields change identity so a schema swap
still revalidates untouched data.

Vue and Angular do not have this bug - neither store runs a watch or
effect, so both already validate exactly once in handleChange.
Mirrors the React adapter so the dirty baseline is reachable from outside
the composable and survives reset(newValues).
Matches the React adapter: the baseline was a const snapshot taken in
setup() and never reassigned. It now tracks the first non-undefined
properties, or comes from initialProperties / form.baselineValues.

The first-seen tracker is a ref rather than a plain binding because the
baseline is a computed, which only re-evaluates on reactive reads.
Completes the three-adapter fix. This adapter turned out to be the least
affected: init() only records a baseline when properties is set, so values
arriving after mount were already handled correctly - the late-load test
added here passed before the fix. What was broken is that the initialised
guard pinned the baseline permanently, so a store reset could never move
it. The new initialProperties input is that escape hatch; pass
store.baselineValues() into it.

The private field is renamed to firstSeenProperties to free the name for
the input, matching React and Vue.
It was hard-coded undefined, so focusFirstInvalidField - which selects
[aria-invalid=true] - had nothing to find for consumers whose renderers
followed the official recipe. makeErrorId defines the convention once,
in one place, for all three adapters.
1.6.0 moved placeholder, min, max, step, accept and multiple to the top
level of FieldDescription. Values left behind in props are overwritten by
the resolved contract and vanish with no throw and no warning - the one
upgrade hazard a consumer cannot diagnose from the outside.

Fires once per field+key and only outside production.
The default renderers forwarded aria-describedby but never rendered the
error they were handed, so the reference had nothing to point at. Emitted
as a fragment sibling - no wrapper element, so layout is unchanged - and
only where no custom renderer is registered, so consumers rendering their
own message do not get a second copy.
Mirrors the React adapter's markup exactly, returned as a fragment array
so no wrapper element appears.

This adapter declares error as [String, Array], unlike React where core
only ever supplies an array, so the message is normalised before use -
indexing a raw string would have rendered its first character.
Completes the three-adapter error node, so aria-describedby resolves on
every adapter instead of dangling.

Two adapter-specific notes. The template uses *ngIf, not the @if block:
the peer range starts at Angular 16 and block control flow is 17+. And
the condition asks the registry directly rather than reading a flag set
in render(), which runs in ngAfterViewInit - by then this template's
bindings are already checked for the pass, and under OnPush nothing
would mark them dirty again.
Also re-exports makeErrorId from all three adapters - check-docs-api-
references caught that the recipe imported it from the react package,
which did not have it. Angular re-exported none of the renderer-prop
helpers, so it gains buildFieldRendererProps, makeFieldId and
FIELD_RENDERER_PROP_KEYS alongside, matching react and vue.
Validation messages could only be set per field, per form, by passing a
string to each validator - so translating a form meant touching every
field description.

t lives on the existing ValidationContext rather than a new parameter:
FieldDescription.validate already takes that context as its fourth
argument, so there was no free slot and no need to invent one. An async
validator gets the resolver for free as a result.
Each validator computed its message when the field description was built,
so a catalog could never reach it. Resolution moves inside the returned
closure, with an explicitly passed string still winning over any catalog -
every existing call site behaves identically.

validators.matches lands in the same change because its default message
needs that machinery; every consumer was hand-writing the same
(value, data) => value !== data.other for confirm-password fields.
validateField and validateFields gain a trailing optional context, so a
catalog reaches the validators - including inside repeatable groups,
where the recursive call now forwards it.

validateFieldsAsync needed no change: its options bag already is the
ValidationContext and was already threaded recursively, so t flows there
as soon as a caller supplies it.
Both validateFieldsAsync call sites spread the context before setting
signal, rather than replacing the options object - dropping the signal
there would silently disable run cancellation.
debounceMs was declared in FieldDescription, published in the .d.ts and
read by no implementation anywhere - setting it did nothing. It now
debounces this loader.

Everything hard lives here rather than three times over in the adapters:
debounce, abort of a superseded run, a run counter that discards an
out-of-order response even when the signal is ignored, and shallow deps
comparison. An AbortError is deliberately not an error state - being
superseded is normal and would otherwise flash a failure on every
keystroke of a search box.

options takes one signature, not a union of sync and async shapes: a
union defeats TypeScript's contextual inference, so every existing
options: (data) => ... would have started erroring under noImplicitAny.
Returning a promise is what makes a loader async. resolveOptions now
returns undefined for those, so no renderer is handed a Promise.
optionsStatus and optionsError join FIELD_RENDERER_PROP_KEYS, so
lint:renderer-parity now requires all three adapters to forward them -
it currently fails on angular, which the next commits fix.

onOptionsQuery is deliberately not in that list: it is a callback,
attached alongside onValueChange and onBlur, and putting it there would
make the parity script check the wrong kind of thing.
Two things this needed beyond wiring the loader in.

FieldInput's memo comparator only compares this field's own slice of the
data, so a field whose optionsDeps read *another* field would never
re-render to notice the change - a country/city pair would load once and
never again. Async-options fields now compare the whole data object.

resolveOptions now drops a promise returned by a loader that detection
missed, and warns with the fix. constructor.name === 'AsyncFunction'
does not survive a memoiser, a spy or a transpiler helper, and handing
the renderer a pending promise as its option list is worse than an empty
one. This mirrors what the validate path already does.
Mirrors the React adapter. The watch is deep on the whole data object
because optionsDeps can read another field's value, and the loader - not
the component - decides whether anything it cares about changed.
Completes the three-adapter loader; lint:renderer-parity now reports 23
props instead of 21.

The loader callback calls markForCheck: these components are OnPush, so
an async arrival happens outside any event the view is checked for and
the options would otherwise load and never appear. onOptionsQuery is
declared on BaseInputComponent only - redeclaring it on DynamicInput is
a TS4114-class error under useDefineForClassFields.
The root README covered the new API; the per-package ones did not mention
any of it. Each adapter README now documents baselineValues,
getDirtyValues, the messages catalog, initialProperties, async options and
the default renderers' new error node, and the core README carries the
full catalog key table and the async options reference the adapters link
to.

One paragraph in the core README had become actively wrong: it still said
ariaDescribedBy is the one prop no adapter fills in. It now explains what
replaced that and why the old reasoning, though sound, left
focusFirstInvalidField doing nothing.
Seven defects in code added by this branch, each with a test that fails
without the fix.

react: the options loader was built in the render body and disposed by
the effect cleanup, but the ref was never cleared - StrictMode's
mount/cleanup/mount left every field holding a disposed loader, so async
options sat at 'loading' forever in any development build. It is now
created lazily and re-created after disposal.

angular: onOptionsQuery never reached a renderer. applyProps iterates
KNOWN_PROPS, which deliberately excludes callbacks, so search-remote was
dead on this adapter despite being documented. Callbacks now have their
own pass, covering the component, the sync and the fallback paths.

angular: the HTML5 fallback never set aria-invalid or aria-describedby,
so the new error node had nothing pointing at it and
focusFirstInvalidField still found nothing - the exact failure the
migration guide claims is fixed. Applied once after the fallback builds,
rather than in each of its four branches.

react: the default error node rendered error[0] of a bare string, which
is its first character. Vue and Angular already normalised; React did
not, and DynamicInput is publicly exported with error typed
string | string[].

core: a retry after a failed load emitted status 'loading' while still
carrying the previous optionsError. The loading transition now clears it.

core: isAsyncOptions returned true for optionsMode: 'async' on a field
whose options is a static array, and fetchNow then threw "load is not a
function" out of a lifecycle hook. It now requires a callable first.

angular: swapping a field from async to synchronous options left the
stale optionsState winning in buildFieldRendererProps, serving the old
list forever with the loader never disposed.

The one finding not fixed is the baseline when properties starts as {}
rather than undefined: {} is a real value, and a form that opens blank
cannot be told apart from one still waiting on a fetch. Characterised by
two tests and documented as a caveat with initialProperties as the
escape hatch.
Changing a field's name swaps the loader (adapters key each field by
name, so it remounts); changing only the options closure on a same-named
field does not.

That asymmetry is deliberate and now has a test saying so. Rebuilding on
closure identity would refetch in a loop for the very common case of a
fields array built inline in a component body, which gets a fresh closure
on every render.
@vannt-dev
vannt-dev merged commit b367bfb into develop Sep 4, 2026
10 checks passed
@vannt-dev
vannt-dev deleted the feat/1.7-triage-followups branch September 4, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant